93. 复原 IP 地址

先给结论

目标是给数字字符串插入 3 个点,将它切成恰好 4 个合法字段。每个字段必须满足:

  • 长度为 1~3。
  • 数值在 0~255 之间。
  • 除了单独的 "0",不能以 "0" 开头。

回溯时维护「当前读取位置」和「已经选择的字段」,每层枚举下一段的 1~3 位。遇到非法字段就 break,当前分支不再往下展开。

题目描述

给定一个只包含数字的字符串 s,在不重排、不删除任何数字的前提下插入 3 个 .,返回所有可能的有效 IPv4 地址。答案可以按任意顺序返回。

示例 1:

输入:s = "25525511135"
输出:["255.255.11.135", "255.255.111.35"]

示例 2:

输入:s = "0000"
输出:["0.0.0.0"]

示例 3:

输入:s = "101023"
输出:[
  "1.0.10.23",
  "1.0.102.3",
  "10.1.0.23",
  "10.10.2.3",
  "101.0.2.3"
]

两道题的长度约束不同:

  • 93 题:1 <= s.length <= 20
  • LCR 087:0 <= s.length <= 3000

由于有效 IPv4 地址去掉点后只能包含 4~12 位数字,长度不在这个范围内时可以直接返回空数组。

合法字段的判断

一个字段合法,当且仅当同时满足:

  1. 字段非空,长度不超过 3。
  2. 如果长度大于 1,首字符不能是 "0"
  3. 数值不超过 255。

例如:

字段是否合法原因
"0"单个零合法
"01"含有前导零
"10"数值在范围内
"255"等于上界
"256"大于 255

回溯设计

递归函数 dfs(start, path) 中:

  • start 表示下一段从 s[start] 开始。
  • path 保存已经确定的字段数组(如 ["255", "255"])。
  • result 保存所有完整且合法的 IP 地址。

每次递归入口都满足以下不变量:

  • path 中的每个字段都合法。
  • path.join('') 恰好等于 s.slice(0, start)
  • path.length <= 4

因此,当 path 恰好有 4 段并且 start === s.length 时,才能加入答案。

为什么选择「扩展运算符」传递 path

由于 path 最多只有 4 个短字符串,拷贝代价可以忽略。用 [...path, seg] 代替 push/pop 的好处是:

  • 不需要关心回溯后的「恢复现场」;
  • 每层递归拿到的是独立的 path,逻辑更纯粹;
  • 避免遗忘 pop() 导致的分支污染。

如果面试官追问,也可以改回 push/pop 以展示对回溯状态恢复的理解。

剪枝规则

越界剪枝

dfs 的 for 循环只枚举字段长度 len = 1, 2, 3。一旦 start + len > s.length,说明剩余字符不足以支撑当前长度,直接 break

前导零剪枝

如果当前字段的第一个字符是 "0",只有单字符 "0" 合法。更长的候选都会保留前导零,因此可以直接 break

数值上界剪枝

同一个起点下,字段从 1 位扩展到 3 位时数值只会增大。一旦数值超过 255,继续增加数字也不可能重新合法,可以 break

为什么不需要「剩余字符数量剪枝」

IPv4 固定 4 段、每段最多 3 位,搜索树深度最多 4、分支因子最多 3。即便输入长度达到 3000,有效的递归调用也只有几十次,因此不必像通用回溯那样显式计算 remainingChars 来做范围剪枝。

示例推演

s = "010010" 为例:

已选字段剩余字符串下一段的有效选择
[]"010010"只能选 "0"
["0"]"10010""1""10""100"
["0", "10"]"010"只能选 "0"
["0", "10", "0"]"10""10",得到 0.10.0.10
["0", "100"]"10""1",最后选 "0",得到 0.100.1.0

其他分支会因为越界、前导零或字段数值超过 255 被剪掉。

最终结果:

["0.10.0.10", "0.100.1.0"]

代码实现

/**
 * 将纯数字字符串切分成所有可能的合法 IPv4 地址。
 *
 * @param {string} s
 * @return {string[]}
 */
var restoreIpAddresses = function (s) {
    // 保存所有已经完成的合法 IP 地址。
    const result = [];

    /**
     * 从 s[start] 开始选择下一个字段。
     *
     * @param {number} start 尚未处理的第一个字符下标
     * @param {string[]} path 已经选择并验证合法的字段
     */
    const dfs = (start, path) => {
        /*
         * IPv4 必须恰好包含 4 个字段。
         *
         * 到达 4 段后不能继续选择:
         * - 如果 start === s.length,说明 4 段刚好用完全部字符,可以记录答案;
         * - 如果 start < s.length,说明还有字符剩余,这条分支不是完整 IP。
         */
        if (path.length === 4) {
            if (start === s.length) {
                result.push(path.join('.'));
            }
            return;
        }

        /*
         * 枚举下一个字段的长度。IPv4 的每个字段只能有 1~3 位,
         * 所以当前递归节点最多产生 3 个子分支。
         */
        for (let len = 1; len <= 3; len++) {
            /*
             * [start, start + len) 超出字符串范围,说明剩余字符不够。
             * len 继续增大只会越界得更严重,因此直接结束循环。
             */
            if (start + len > s.length) break;

            // 截取当前候选字段,slice 的右边界 start + len 不包含在结果中。
            const seg = s.slice(start, start + len);

            /*
             * 当前字段不合法时直接 break,而不是 continue。
             *
             * 原因是从同一个 start 出发,后面的候选只会在 seg 右侧继续加数字:
             * - 如果 seg 因前导零非法,更长字段仍然有前导零;
             * - 如果 seg 已大于 255,更长字段只会更大。
             *
             * 因此当前起点后续的长度都无须再尝试。
             */
            if (!valid(seg)) break;

            /*
             * 当前字段合法,将读取位置向右移动 len 位,并递归选择下一段。
             *
             * [...path, seg] 会创建一个新数组,所以当前层的 path 不会被修改。
             * 递归返回后可以直接尝试下一个 len,不需要手动执行 path.pop()。
             */
            dfs(start + len, [...path, seg]);
        }
    };

    // 从字符串下标 0 开始,此时还没有选择任何字段。
    dfs(0, []);

    // DFS 结束后,result 中保存了所有合法切分方案。
    return result;
};

/**
 * 判断一个候选字符串能否作为 IPv4 的一个字段。
 *
 * 调用方已经保证 str 的长度在 1~3 之间,因此这里只需检查:
 * 1. 是否存在前导零;
 * 2. 数值是否不超过 255。
 *
 * @param {string} str
 * @return {boolean}
 */
function valid(str) {
    /*
     * 以 0 开头时,只有单独的 "0" 合法。
     * "00"、"01"、"010" 等都含有前导零。
     */
    if (str[0] === '0') return str.length === 1;

    // 一元加号把数字字符串转为 number,再检查 IPv4 字段的数值上界。
    return +str <= 255;
}

另一种实现:使用共享 path

path 不一定要作为 dfs 的参数传递。也可以把它定义在 restoreIpAddresses 内部、dfs 外部,让所有递归层通过闭包访问同一个数组。

此时 dfs 只需要接收当前读取位置 start

/**
 * @param {string} s
 * @return {string[]}
 */
var restoreIpAddresses = function (s) {
    const result = [];

    /*
     * 所有递归层共享同一个 path。
     * 它位于函数内部,因此每次调用 restoreIpAddresses 都会创建独立数组,
     * 不会受到上一次调用的影响。
     */
    const path = [];

    /**
     * 从 s[start] 开始选择下一个字段。
     *
     * path 不需要作为参数传入,因为 dfs 可以通过闭包直接访问它。
     *
     * @param {number} start 尚未处理的第一个字符下标
     */
    const dfs = (start) => {
        // 已经选择 4 段后,判断是否也恰好用完了全部字符。
        if (path.length === 4) {
            if (start === s.length) {
                /*
                 * join() 返回一个新字符串,不会把 path 数组本身存入 result,
                 * 所以后续的 path.pop() 不会修改已经保存的答案。
                 */
                result.push(path.join('.'));
            }
            return;
        }

        // 下一段的长度只能是 1、2 或 3。
        for (let len = 1; len <= 3; len++) {
            // 剩余字符不足时,后续更大的 len 也会越界。
            if (start + len > s.length) break;

            const segment = s.slice(start, start + len);

            /*
             * 当前字段非法后,更长字段也不会合法:
             * 前导零会一直保留,超过 255 的数字也只会继续增大。
             */
            if (!isValidSegment(segment)) break;

            // 1. 做选择:把当前字段加入路径。
            path.push(segment);

            // 2. 进入下一层:从当前字段之后继续切分。
            dfs(start + len);

            /*
             * 3. 撤销选择:恢复进入当前分支前的 path。
             *
             * 所有分支共享同一个数组,如果缺少 pop(),当前字段会残留,
             * 进而污染 for 循环中的下一个分支。
             */
            path.pop();
        }
    };

    dfs(0);
    return result;
};

/**
 * 判断字段是否合法。
 *
 * @param {string} segment
 * @return {boolean}
 */
function isValidSegment(segment) {
    // 多位字段不能以 0 开头,但单独的 "0" 合法。
    if (segment.length > 1 && segment[0] === '0') {
        return false;
    }

    return Number(segment) <= 255;
}

这种写法的核心模板是:

path.push(segment); // 做选择
dfs(nextStart);     // 递归处理下一层
path.pop();         // 撤销选择

两种实现只是管理路径状态的方式不同:

写法递归调用状态恢复
path 作为参数dfs(nextStart, [...path, segment])每层使用新数组,不需要恢复
使用共享 pathdfs(nextStart)复用同一数组,递归后必须 pop()

共享 path 应该定义在 restoreIpAddresses 内部,而不应该定义成文件级全局变量。这样既能让递归层共享状态,也能避免多次调用函数时互相干扰。

代码执行过程详解

可以把 dfs(start, path) 理解为一个问题:

字符串下标 start 之前的内容已经被切成了 path 中的合法字段,接下来应该从 s[start] 开始截取几位?

例如处理 s = "25525511135" 时,某次递归可能处于:

start = 6
path = ["255", "255"]
已处理部分 = "255255"
未处理部分 = "11135"

此时第三段从 s[6] 开始,循环依次尝试:

len = 1:seg = "1"
len = 2:seg = "11"
len = 3:seg = "111"

这三个字段都合法,所以分别形成三个递归分支。以选择 "11" 的分支为例:

dfs(start + len, [...path, seg]);
// 等价于:
dfs(6 + 2, ["255", "255", "11"]);

下一层的状态就是:

start = 8
path = ["255", "255", "11"]
未处理部分 = "135"

这一层选择 "135" 后,会进入:

start = 11
path = ["255", "255", "11", "135"]

此时 path.length === 4,并且 start === s.length,两个条件同时成立,于是执行:

result.push(path.join('.'));

"255.255.11.135" 加入答案。

递归返回后发生了什么

当一个分支执行结束,程序会回到上一层 for 循环,继续尝试更长的字段。例如某层先尝试了 1 位字段,递归返回后还会继续尝试 2 位、3 位字段。

由于递归时传入的是:

[...path, seg]

每个分支都有自己的新数组。子递归对路径的扩展不会改变父层的 path,因此返回父层时状态天然保持不变。

如果使用传统的共享数组写法,同一段逻辑会是:

path.push(seg);      // 做选择
dfs(start + len, path);
path.pop();          // 撤销选择,恢复父层状态

两种写法的回溯过程相同,只是当前实现通过复制小数组省去了显式的“撤销选择”。

两个结束条件为什么缺一不可

代码到达 4 段时,会同时检查:

path.length === 4
start === s.length

这是因为以下两类状态都不能作为答案:

path = ["1", "1", "1"],start === s.length

字符虽然用完了,但只有 3 段;

path = ["1", "1", "1", "1"],start < s.length

虽然已经有 4 段,但还有字符未使用。

只有“恰好选择 4 段”和“恰好用完字符串”同时成立,才是完整的 IPv4 地址。

代码与思路对照

阶段对应代码作用
初始化result保存答案
结束条件path.length === 4只有恰好用完字符串时才记录答案
枚举字段len = 1; len <= 3下一段只可能包含 1~3 位
越界剪枝start + len > s.length字符不够时停止
前导零剪枝str[0] === '0'禁止 "00""01" 等字段
数值剪枝+str <= 255禁止超过 IPv4 字段上界
递归与回溯[...path, seg]用扩展运算符传入新路径,无需 pop

正确性说明

可以从「生成的答案都合法」和「不会漏掉合法答案」两方面证明。

生成的答案都合法

加入 path 的字段都通过了 valid() 检查(长度天然在 1~3 内、无前导零、数值不超过 255)。算法只有在 path 恰好包含 4 段且所有字符都被使用时才记录结果,因此生成的每个字符串都是合法 IPv4 地址。

不会漏掉合法答案

任意合法 IPv4 地址都由 4 个长度为 1~3 的合法字段组成。DFS 会从每个字段起点依次尝试长度 1、2、3,因此一定会枚举到该地址的四个字段。

被剪掉的分支只可能是:

  • 字段存在前导零;
  • 字段数值超过 255;
  • 剩余字符不足以放下当前枚举长度(start + len > s.length)。

这些情况都不可能属于合法答案,所以剪枝不会漏解。

边界与陷阱

  • 长度不在 4~12: 不可能组成 4 个字段,算法会在根节点快速结束,返回空数组。
  • 全零字符串: "0000" 只能得到 "0.0.0.0"
  • 前导零: "010010" 中可以选择 "0",但不能选择 "01""010"
  • 数值边界: "255" 合法,"256" 非法。
  • 必须恰好四段: 不能只判断字符串是否用完,也不能选择完四段后继续递归。
  • 必须使用全部字符: 四段合法但仍有剩余字符时不能记录答案。
  • 答案顺序: 题目允许按任意顺序返回,不需要额外排序。

复杂度分析

IPv4 固定为 4 段,每段最多尝试 3 种长度,因此搜索树的状态数存在与输入长度无关的常数上界:

1 + 3 + 3² + 3³ + 3⁴

每次只处理至多 3 个字符。由于 4 和 3 都是 IPv4 的固定常数,也可以记为:

  • 时间复杂度:O(1)
  • 额外空间复杂度:O(1),不计返回结果。

长度超过 12 的输入仍会以常数时间结束,因为递归深度被字段数限制在 4 层。

面试官递进追问

1. 为什么本题适合回溯?

三个点的位置存在多种选择,而每次选择都会影响后续剩余字符。回溯可以枚举每一段的长度,并在发现当前前缀不可能形成合法 IP 时立即停止。

2. 递归函数需要维护哪些状态?

只需要当前读取位置 start 和已经选择的字段 path。原字符串和答案数组在闭包中共享,不需要为每层复制剩余字符串。

3. 用 push/pop 和用 [...path, seg] 有什么区别?

push/pop 复用同一个数组,需要手动恢复现场,空间更省;[...path, seg] 每层创建新数组,逻辑更纯粹,不用担心遗忘 pop()。本题中 path 极短,两种写法都可以接受。

4. 为什么遇到前导零后可以直接 break

当字段以 "0" 开头时,只有单字符 "0" 合法。更长的候选都会保留这个前导零,因此都不合法,可以停止当前起点的后续枚举。

5. 为什么字段数值超过 255 后也可以 break

当前字段只包含数字,继续在右侧添加数字不会让它重新落回 0~255,所以更长候选同样非法。

6. 为什么结束条件要同时检查字段数和字符串位置?

只有「恰好 4 段」和「恰好用完所有字符」同时满足才是完整地址。缺少任一条件,都可能接受三段地址或仍有字符残留的错误结果。

7. 为什么复杂度可以写成 O(1)

IPv4 的字段数固定为 4,每段长度固定不超过 3,搜索状态数量存在与输入长度无关的常数上界。

8. 不用回溯还能怎么做?

可以枚举三个点的位置 i < j < k,分别验证 s[0..i)s[i..j)s[j..k)s[k..n)。本质上仍是在枚举所有切分位置,代码可能更直接,但字段合法性判断不能省略。

常见错误

  • "0" 和含前导零的 "00""01" 混为一谈。
  • 只限制字段长度,没有检查数值是否超过 255。
  • 已选 4 段后仍继续递归,产生无意义的更深搜索。
  • 字符串用完就记录答案,却没有检查是否恰好得到 4 段。
  • 使用共享的 path 配合 push/pop 后忘记 pop(),污染其他递归分支。

可迁移总结

  • 分割类回溯: 状态通常是当前位置和已经选择的片段。
  • 单调剪枝: 候选继续扩展只会更坏时,可以使用 break,不必只跳过当前候选。
  • 不可变路径: 小规模路径数组可以考虑用扩展运算符传递,降低心智负担。
  • 一句话记忆: 枚举每段 1~3 位,同时剪掉越界、前导零和大于 255 的分支。

刷题后自测

先只回答第 1 题,再展开后续问题:

  1. 为什么 valid() 里不需要显式判断 str.length > 3
完成第 1 题后再看第 2 题
  1. 输入 "0000" 时,为什么每一层都只能选择一位?
完成前两题后再看第 3 题
  1. 如果只检查 path.length === 4,却不检查 start === s.length,会接受什么错误结果?
完成前三题后再看第 4 题
  1. 尝试把 [...path, seg] 改回 push/pop 写法,并说明两者的优劣。